useUiStore(UI 狀態)與 useProjectsStore(資料/快取)npm i pinia
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './styles/base.css'
createApp(App)
  .use(router)
  .use(createPinia())
  .mount('#app')
// src/stores/ui.ts
import { defineStore } from 'pinia'
type Theme = 'light' | 'dark'
type Cat = 'all' | 'frontend' | 'backend' | 'tools'
export const useUiStore = defineStore('ui', {
  state: () => ({
    theme: 'light' as Theme,
    skillCategory: 'all' as Cat,
    keyword: '' as string,
  }),
  getters: {
    isDark: (s) => s.theme === 'dark',
  },
  actions: {
    setTheme(theme: Theme) {
      this.theme = theme
      // 立即反映到 <html data-theme=...>
      document.documentElement.setAttribute('data-theme', theme)
      // 持久化(簡易版)
      localStorage.setItem('ui.theme', theme)
    },
    toggleTheme() {
      this.setTheme(this.isDark ? 'light' : 'dark')
    },
    setSkillCategory(cat: Cat) { this.skillCategory = cat; localStorage.setItem('ui.cat', cat) },
    setKeyword(kw: string) { this.keyword = kw; localStorage.setItem('ui.kw', kw) },
    // 初始化:將 localStorage 的值載回
    initFromStorage() {
      const t = localStorage.getItem('ui.theme') as Theme | null
      const c = localStorage.getItem('ui.cat') as Cat | null
      const k = localStorage.getItem('ui.kw')
      if (t) this.setTheme(t)
      if (c) this.skillCategory = c
      if (k !== null) this.keyword = k
    }
  }
})
小提醒:我們直接在 action 裡同步更新 document.documentElement,確保切換主題立即生效。
// src/stores/projects.ts
import { defineStore } from 'pinia'
export type Project = {
  id: number
  slug: string
  title: string
  tech: string
  desc: string
  tags: string[]
  images: string[]
  demo: string
  repo: string
  featured: boolean
}
export const useProjectsStore = defineStore('projects', {
  state: () => ({
    items: [] as Project[],
    loaded: false,
    loading: false,
    error: null as Error | null,
    lastFetchedAt: null as number | null,
  }),
  getters: {
    count: (s) => s.items.length,
    bySlug: (s) => (slug: string) => s.items.find(p => p.slug === slug),
  },
  actions: {
    async fetchAll(force = false) {
      if (this.loaded && !force) return
      this.loading = true
      this.error = null
      try {
        // 你可以改成真的 API,例如:/api/projects
        const res = await fetch('/projects.json', { cache: 'no-cache' })
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        const data = await res.json() as Project[]
        this.items = data
        this.loaded = true
        this.lastFetchedAt = Date.now()
      } catch (err: any) {
        this.error = err
        this.items = []
        this.loaded = false
      } finally {
        this.loading = false
      }
    }
  }
})
// src/App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import SiteFooter from '@/components/SiteFooter.vue'
import { onMounted } from 'vue'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
onMounted(() => {
  ui.initFromStorage()            // 從 localStorage 讀回
  // 初次掛載依 Theme 設置 data-theme(init 已做,不過再保險一次)
  document.documentElement.setAttribute('data-theme', ui.theme)
})
</script>
<template>
  <SiteHeader />
  <RouterView />
  <SiteFooter />
</template>
<!-- src/components/SiteHeader.vue -->
<template>
  <header class="site-header">
    <div class="container">
      <a class="brand" href="#home" aria-label="回到頁面頂部">Chiayu</a>
      <nav class="site-nav" aria-label="主選單">
        <ul>
          <li><a href="#about">關於我</a></li>
          <li><a href="#skills">技能</a></li>
          <li><a href="#projects">作品</a></li>
          <li><a href="#contact">聯絡</a></li>
        </ul>
      </nav>
      <button class="btn btn-outline small" type="button" @click="ui.toggleTheme()">
        {{ ui.isDark ? '切換為亮色' : '切換為暗色' }}
      </button>
    </div>
  </header>
</template>
<script setup lang="ts">
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
</script>
<!-- src/components/Skills.vue -->
<template>
  <section id="skills" class="container section" aria-labelledby="skills-title">
    <div class="section-header">
      <h2 id="skills-title">技能 Skillset</h2>
      <div role="tablist" aria-label="技能分類" class="filters">
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='all'"      @click="ui.setSkillCategory('all')">全部</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='frontend'" @click="ui.setSkillCategory('frontend')">前端</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='backend'"  @click="ui.setSkillCategory('backend')">後端</button>
        <button class="chip" role="tab" :aria-selected="ui.skillCategory==='tools'"    @click="ui.setSkillCategory('tools')">工具</button>
      </div>
    </div>
    <div class="field" style="margin:12px 0;">
      <label for="skill-search">關鍵字搜尋</label>
      <input id="skill-search" type="text" :value="ui.keyword" @input="onInput" placeholder="例如:Vue、Docker…" />
    </div>
    <ul class="skill-grid">
      <li v-for="s in filtered" :key="s.name">{{ s.name }}</li>
    </ul>
  </section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useUiStore } from '@/stores/ui'
import { skills } from '@/data/skills'
const ui = useUiStore()
// 簡易 debounce
let tid: number | undefined
function onInput(e: Event) {
  const v = (e.target as HTMLInputElement).value
  window.clearTimeout(tid)
  tid = window.setTimeout(() => ui.setKeyword(v.trim()), 300)
}
const filtered = computed(() => {
  const kw = ui.keyword.toLowerCase()
  return skills.filter(s => {
    const byCat = ui.skillCategory === 'all' || s.category === ui.skillCategory
    const byKw  = !kw || s.name.toLowerCase().includes(kw)
    return byCat && byKw
  })
})
</script>
<!-- src/components/Projects.vue -->
<template>
  <section id="projects" class="container section" aria-labelledby="projects-title">
    <h2 id="projects-title">作品集 Projects</h2>
    <div class="field" style="margin:12px 0;">
      <label><input type="checkbox" v-model="onlyFeatured" /> 只看精選</label>
    </div>
    <p v-if="ps.loading">載入中...</p>
    <p v-else-if="ps.error" class="error">載入失敗,請稍後再試。</p>
    <div v-else class="project-grid">
      <article class="card" v-for="p in view" :key="p.id">
        <h3>{{ p.title }}</h3>
        <p class="muted">{{ p.tech }}</p>
        <p>{{ p.desc }}</p>
        <div style="display:flex; gap:8px; margin-top:8px;">
          <RouterLink class="btn small" :to="{ name:'project-detail', params:{ slug: p.slug } }">查看詳情</RouterLink>
          <a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </article>
    </div>
  </section>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useProjectsStore } from '@/stores/projects'
const ps = useProjectsStore()
const onlyFeatured = ref(false)
const view = computed(() => {
  const list = ps.items
  return onlyFeatured.value ? list.filter(p => p.featured) : list
})
onMounted(() => { ps.fetchAll().catch(() => {}) })
</script>
<!-- src/views/ProjectDetail.vue -->
<template>
  <section class="container section">
    <nav style="margin-bottom:12px;">
      <RouterLink to="/" class="btn btn-outline">← 返回列表</RouterLink>
    </nav>
    <p v-if="ps.loading">載入中...</p>
    <p v-else-if="ps.error" class="error">讀取失敗,請返回列表。</p>
    <template v-else>
      <section v-if="project">
        <h2>{{ project.title }}</h2>
        <p class="muted">{{ project.tech }}</p>
        <div class="gallery" v-if="project.images?.length" style="display:flex; gap:12px; flex-wrap:wrap; margin:12px 0;">
          <img v-for="src in project.images" :key="src" :src="src" alt="專案截圖" width="360" />
        </div>
        <p>{{ project.desc }}</p>
        <div style="display:flex; gap:8px; margin-top:8px;">
          <a class="btn" :href="project.demo" target="_blank" rel="noopener">Live Demo</a>
          <a class="btn btn-outline" :href="project.repo" target="_blank" rel="noopener">GitHub</a>
        </div>
      </section>
      <section v-else>
        <h2>找不到這個專案</h2>
        <p class="muted">請回到列表,或確認網址是否正確。</p>
        <RouterLink to="/" class="btn">返回列表</RouterLink>
      </section>
    </template>
  </section>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useProjectsStore } from '@/stores/projects'
const route = useRoute()
const ps = useProjectsStore()
const slug = computed(() => String(route.params.slug || ''))
const project = computed(() => ps.bySlug(slug.value))
onMounted(async () => {
  if (!project.value && !ps.loading) {
    try { await ps.fetchAll() } catch {}
  }
})
</script>
如果想讓 Pinia 自動持久化,安裝插件 pinia-plugin-persistedstate:
npm i pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPersist)
createApp(App).use(router).use(pinia).mount('#app')
// ui.ts(加入 persist)
export const useUiStore = defineStore('ui', {
  /* ...state/getters/actions 同上... */
  persist: {
    key: 'ui',
    paths: ['theme', 'skillCategory', 'keyword'] // 只存這些欄位
  }
})
getter 裡改 DOM 或寫 localStorageaction
ref 存 keyword,同時 store 也存 → 產生不同步loading/error,UI 顯示三態(loading / error / data)Vue 版收尾與最佳化 & 部署:
views / components / stores / router / styles 的專案結構<link rel="preload"> / srcset)